热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

祖先|目的地_logback架构

篇首语:本文由编程笔记#小编为大家整理,主要介绍了logback架构相关的知识,希望对你有一定的参考价值。logback的架构

篇首语:本文由编程笔记#小编为大家整理,主要介绍了logback架构相关的知识,希望对你有一定的参考价值。



logback 的架构



一个著名的日志系统是怎么设计出来的强烈推荐一下这篇博文,它可以让你详细的了解java日志发展史



Logger, Appender 和 Layouts

Logback 构建在三个主要的类上:Logger,Appender 和 Layouts。这三个不同类型的组件一起作用能够让开发者根据消息的类型以及日志的级别来打印日志。

Logger 类作为 logback-classic 模块的一部分。Appender 与 Layouts 接口作为 logback-core 的一部分。作为一个通用的模块,logback-core 没有 logger 的概念。


Logger 上下文(LoggerContext)

任何日志 API 的优势在于它能够禁止某些日志的输出,但是又不会妨碍另一些日志的输出。通过假定一个日志空间,这个空间包含所有可能的日志语句,这些日志语句根据开发人员设定的标准来进行分类。在 logback-classic 中,分类是 logger 的一部分,每一个 logger 都依附在 LoggerContext 上,它负责产生 logger,并且通过一个树状的层级结构来进行管理。
一个 Logger 被当作为一个实体,它们的命名是大小写敏感的,并且遵循一下规则:



命名层次结构


如果一个 logger 的名字加上一个 . 作为另一个 logger 名字的前缀,那么该 logger 就是另一个 logger 的祖先。如果一个 logger 与另一个 logger 之间没有其它的 logger ,则该 logger 就是另一个 logger 的父级。


例如:名为 com.foo 的 logger 是名为 com.foo.Bar 的 logger 的父级。名为 java 的 logger 是名为 java.util 的父级,是名为 java.util.Vector 的祖先。

root logger 作为 logger 层次结构的最高层。它是一个特殊的 logger,因为它是每一个层次结构的一部分。每一个 logger 都可以通过它的名字去获取。例:

Logger rootLogger = LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME)

所有其它的 logger 通过 org.slf4j.LoggerFactory 类的静态方法 getLogger 去获取,这个方法需要传入一个 logger 的名字。下面是 Logger 接口一些基本的方法:

package org.slf4j;
public interface Logger
public void trace(String message);
public void debug(String message);
public void info(String message);
public void warn(String message);
public void error(String message);


logback的等级继承特性

Logger 能够被分成不同的等级。不同的等级(TRACE, DEBUG, INFO, WARN, ERROR)定义在 ch.qos.logback.classic.Level 类中。在 logback 中,类 Level 使用 final 修饰的,所以它不能用来被继承。一种更灵活的方式是使用 Marker 对象。

如果一个给定的 logger 没有指定一个层级,那么它就会继承离它最近的一个祖先的层级。更正式的说法是:



对于一个给定的名为 L 的 logger,它的有效层级为从自身一直回溯到 root logger,直到找到第一个不为空的层级作为自己的层级。


为了确保所有的 logger 都有一个层级,root logger 会有一个默认层级 DEBUG

以下四个例子指定不同的层级,以及根据继承规则得到的最终有效层级


实例1


logger的名字日志级别有效级别
rootDEBUGDEBUG
XnoneDEBUG
X.YnoneDEBUG
X.Y.ZnoneDEBUG

在这个例子中,只有 root logger 被指定了级别,所以 logger X,X.Y,X.Y.Z 的有效级别都是 DEBUG。


实例2


logger的名字日志级别有效级别
rootDEBUGDEBUG
XINFOINFO
X.YDEBUGDEBUG
X.Y.ZWARNWARN

在这个例子中,每个 logger 都分配了级别,所以有效级别就是指定的级别。


实例3


logger的名字日志级别有效级别
rootDEBUGDEBUG
XINFOINFO
X.YnoneINFO
X.Y.ZWARNWARN

在这个例子中,logger root,X,X.Y.Z 都分别分配了级别。logger X.Y 继承它的父级 logger X。


方法打印以及基本选择规则

根据定义,打印的方法决定的日志的级别。例如:L 是一个 logger 实例,L.info(“…”) 的日志级别就是 INFO。

如果一条的日志的打印级别大于 logger 的有效级别,该条日志才可以被打印出来。这条规则总结如下:



基本选择规则


日志的打印级别为 p,Logger 实例的级别为 q,如果 p >= q,则该条日志可以打印出来。


这条规则是 logbakc 的核心。各级别的排序为:TRACE

在下面的表格中,第一列表示的是日志的打印级别,用 p 表示。第一行表示的是 logger 的有效级别,用 q 表示。行列交叉处的结果表示由—基本选择规则 得出的结果。

实例:

package chapters.architecture;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ch.qos.logback.classic.Level;
public class SelectionRule
public static void main(String[] args)
// ch.qos.logback.classic.Logger 可以设置日志的级别
// 获取一个名为 "com.foo" 的 logger 实例
ch.qos.logback.classic.Logger logger =
(ch.qos.logback.classic.Logger)LoggerFactory.getLogger("com.foo");
// 设置 logger 的级别为 INFO
logger.setLevel(Level.INFO);
// 这条日志可以打印,因为 WARN >= INFO
logger.warn("警告信息");
// 这条日志不会打印,因为 DEBUG
logger.debug("调试信息");
// "com.foo.bar" 会继承 "com.foo" 的有效级别
Logger barLogger = LoggerFactory.getLogger("com.foo.bar");
// 这条日志会打印,因为 INFO >= INFO
barLogger.info("子级信息");
// 这条日志不会打印,因为 DEBUG
barLogger.debug("子级调试信息");



获取 Logger

通过 LoggerFactory.getLogger() 可以获取到具体的 logger 实例,名字相同则返回的 logger 实例也相同。由于logback实现了slf4j,因此使用slf4j的Logger抽象类,和工厂类即可

Logger x = LoggerFactory.getLogger("wombat");
Logger y = LoggerFactory.getLogger("wombat");

x,y 是同一个 logger 对象。

可以通过配置一个 logger,然后在其它地方获取,而不需要传递引用。父级 logger 总是优于子级 logger,并且父级 logger 会自动寻找并关联子级 logger,即使父级 logger 在子级 logger 之后实例化。

logback 环境的配置会在应用初始化的时候完成。最优的方式是通过读取配置文件。

在每个类里面通过指定全限定类名为 logger 的名字来实例化一个 logger 是最好也是最简单的方式。因为日志能够输出这个 logger 的名字,所以这个命名策略能够看出日志的来源是哪里。虽然这是命名 logger 常见的策略,但是 logback 不会严格限制 logger 的命名,你完全可以根据自己的喜好来,你开心就好。

但是,根据类的全限定名来对 logger 进行命名,是目前最好的方式,没有之一。


Appender 与 Layout

有选择的启用或者禁用日志的输出只是 logger 的一部分功能。logback 允许日志在多个地方进行输出。站在 logback 的角度来说,输出目的地叫做 appender。appender 包括console、file、remote socket server、mysql、PostgreSQL、Oracle 或者其它的数据库、JMS、remote UNIX Syslog daemons 中。



一个 logger 可以有多个 appender。


logger 通过 addAppender 方法来新增一个 appender。对于给定的 logger,每一个允许输出的日志都会被转发到该 logger 的所有 appender 中去。换句话说,appender 从 logger 的层级结构中去继承叠加性。例如:如果 root logger 添加了一个 console appender,所有允许输出的日志至少会在控制台打印出来。如果再给一个叫做 L 的 logger 添加了一个 file appender,那么 L 以及 L 的子级 logger 都可以在文件和控制台打印日志。可以通过设置 additivity = false 来改写默认的设置,这样 appender 将不再具有叠加性。



appender 的叠加性


logger L 的日志输出语句会遍历 L 和它的子级中所有的 appender。这就是所谓的 appender 叠加性(appender additivity)


如果 L 的子级 logger 为 P,且 P 设置了 additivity = false,那么 L 的日志会在 L 所有 的 appender 包括 P 本身的 appender 中输出,但是不会在 P 的子级 appender 中输出。


logger 默认设置 additivity = true。



Logger名被附加的 AppendersAdditivity Flag输出目标描述
rootA1不适用A1root logger 为 logger 层级中的最高层,additivity 对它不适用
xA-x1, A-x2trueA1, A-x1, A-x2x 与 root 的 appender
x.ynonetrueA1, A-x1, A-x2x 与 root 的 appender
x.y.zA-xyz1trueA1, A-x1, A-x2, A-xyz1x 与 x.y 与 root 的 appender
securityA-secfalseA-sec因为 additivity = false,所以只有 A-sec 这个 appender
security.accessnonetrueA-sec因为它的父级 logger security 设置了 additivity = false,所以只有 A-sec 这一个 appender

通常,用户既想自定义日志的输出地,也想自定义日志的输出格式。通过给 appender 添加一个 layout 可以做到。layout 的作用是将日志格式化,而 appender 的作用是将格式化后的日志输出到指定的目的地。PatternLayout 能够根据用户指定的格式来格式化日志,类似于 C 语言的 printf 函数。

例:PatternLayout 通过格式化串 “%-4relative [%thread] %-5level %logger32 - %msg%n” 会将日志格式化成如下结果:

176 [main] DEBUG manual.architecture.HelloWorld2 - Hello world.

第一个参数表示程序启动以来的耗时,单位为毫秒。第二个参数表示当前的线程号。第三个参数表示当前日志的级别。第四个参数是 logger 的名字。“-” 之后是具体的日志信息。


参数化日志

考虑到 logback-classic 实现了 SLF4J 的 Logger 接口,一些打印方法可以接收多个传参。这些打印方法的变体主要是为了提高性能以及减少对代码可读性的影响。

对于一些 Logger 如下输出日志:

logger.debug("Entry number: " + i + " is " + String.valueOf(entry[i]));

会产生构建消息参数的成本,是因为需要将整数转为字符串,然后再将字符串拼接起来。但是我们是不需要关心 debug 信息是否被记录(强行曲解作者的意思)。

为了避免构建参数带来的损耗,可以在日志记录之前做一个判断,如下:

if(logger.isDebugEnabled())
logger.debug("Entry number: " + i + " is " + String.valueOf(entry[i]));

在这种情况下,如果 logger没有开启 debug 模式,不会有构建参数带来的性能损耗。换句话说,如果 logger 在 debug 级别,将会有两次性能的损耗,一次是判断是否启用了 debug 模式,一次是打印 debug 日志。在实际应用当中,这种性能上的损耗是可以忽略不计的,因为它所花费的时间小于打印一条日志的时间的 1%。


更好的选择

有一种更好的方式去格式化日志信息。假设 entry 是一个 Object 对象:

Object entry = new SomeObject();
logger.debug("The entry is ", entry);

只有在需要打印 debug 信息的时候,才会去格式化日志信息,将 ‘’ 替换成 entry 的字符串形式。也就是说在这种情况下,如果禁止了日志的打印,也不会有构建参数上的性能消耗。

下面两行输出的结果是一样的,但是一旦禁止日志打印,第二个变量的性能至少比第一个变量好上 30 倍。

logger.debug("The new entry is " + entry + ".");
logger.debug("The new entry is ", entry);

使用两个参数的例子如下:

logger.debug("The new entry is , It replaces .", entry, oldEntry);

如果需要使用三个或三个以上的参数,可以采用如下的形式:

Object[] paramArray = newVal, below, above;
logger.debug("Value was inserted between and .", paramArray);

logback的底层实现

在介绍了基本的 logback 组件之后,我们准备介绍一下,当用户调用日志的打印方法时,logback 所执行的步骤。现在我们来分析一下当用户通过一个名为 com.wombat 的 logger 调用了 info() 方法时,logback 执行了哪些步骤。


第一步:获取过滤器链

如果存在,则 TurboFilter 过滤器会被调用,Turbo 过滤器会设置一个上下文的阀值,或者根据每一条相关的日志请求信息,例如:Marker, Level, Logger, 消息,Throwable 来过滤某些事件。如果过滤器链的响应是 FilterReply.DENY,那么这条日志请求将会被丢弃。如果是 FilterReply.NEUTRAL,则会继续执行下一步,例如:第二步。如果响应是 FilterRerply.ACCEPT,则会直接跳到第三步。


第二步:应用基本选择规则

在这步,logback 会比较有效级别与日志请求的级别,如果日志请求被禁止,那么 logback 将会丢弃调这条日志请求,并不会再做进一步的处理,否则的话,则进行下一步的处理。


第三步:创建一个 LoggingEvent 对象

如果日志请求通过了之前的过滤器,logback 将会创建一个 ch.qos.logback.classic.LoggingEvent 对象,这个对象包含了日志请求所有相关的参数,请求的 logger,日志请求的级别,日志信息,与日志一同传递的异常信息,当前时间,当前线程,以及当前类的各种信息和 MDC。MDC 将会在后续章节进行讨论。


第四步:调用 appender

在创建了 LoggingEvent 对象之后,logback 会调用所有可用 appender 的 doAppend() 方法。这些 appender 继承自 logger 上下文。

所有的 appender 都继承了 AppenderBase 这个抽象类,并实现了 doAppend() 这个方法,该方法是线程安全的。AppenderBase 的 doAppend() 也会调用附加到 appender 上的自定义过滤器。自定义过滤器能动态的动态的添加到 appender 上,在过滤器章节会详细讨论。


第五步:格式化输出

被调用的 appender 负责格式化日志时间。但是,有些 appender 将格式化日志事件的任务委托给 layout。layout 格式化 LoggingEvent 实例并返回一个字符串。对于其它的一些 appender,例如 SocketAppender,并不会把日志事件转变为一个字符串,而是进行序列化,因为,它们就不需要一个 layout。


第六步:发送 LoggingEvent

当日志事件被完全格式化之后将会通过每个 appender 发送到具体的目的地。

下图是 logback 执行步骤的 UML 图:


性能

记录日志经常被提到的一个点是它的计算代价。这是一个合理的考虑,因为一个中等大小的应用都可以产生成千上万的日志。我们的大部分努力都花在了测量以及调整 logback 的性能。但是用户还是应该知道以下有关性能的问题。


  • 当日志记录被关闭时记录日志的性能

通过设置 root logger 的日志级别为 Level.OFF 来完全关闭日志的打印。当日志完全关闭的时候,日志请求的成本为方法的调用以及整数的比较。在 3.2Ghz 奔腾D 的电脑上的耗时大约为 20 纳秒。

任何方法的调用都有参数构建这个隐含的成本在里面。例如下面这个例子:

x.debug("Entry number: " + i + "is " + entry[i]);
把整数 i、entry[i] 转变为字符串,并且连接在一起,而不管这条日志是否会被打印。

构建参数的成本取决于参数的大小,为了避免不必要的性能损耗,可以使用 SLF4J’s 的参数化构建:

x.debug(“Entry number: is ”, i, entry[i]);
这种形式不会有构建参数的成本在里面。与上一个例子做比较,这个的速度会更快。只有当日志信息传递给了附加的 appender 时才会被格式化,而且格式化日志信息的组件也是被优化过的。


  • 当日记记录被打开时是否记录日志的性能
    在 logback 中,不需要遍历 logger 的层次结构。logger 在创建的时候就知道自己的有效级别。如果父级 logger 的级别被更改,则会通知所有子级 logger 注意这个更改。因此,在基于有效级别的基础上,logger 能够准实时的做出决定是否接受或者拒绝日志请求,而不需要考虑它的祖先的级别。

  • 日记记录的实际情况(格式化输出到指定设备)
    这是指格式化日志输出以及发送指定的目的地所需要的成本。我们尽可能快的让 layout(格式化)以及 appender 执行。在本地机器上,将日志输出到文件大概耗费 9-12 微秒的时间。当把日志输出到数据库或者远程服务器上时会上升到几毫秒。

尽管 logback 功能丰富,但是它最重要的目标之一是处理速度,这是仅次于可靠性的要求。为了提高性能,一些 logback 的组件被重写了几次。


官方实例代码


Bar.java

/**
* Logback: the reliable, generic, fast and flexible logging framework.
* Copyright (C) 1999-2015, QOS.ch. All rights reserved.
*
* This program and the accompanying materials are dual-licensed under
* either the terms of the Eclipse Public License v1.0 as published by
* the Eclipse Foundation
*
* or (per the licensee's choosing)
*
* under the terms of the GNU Lesser General Public License version 2.1
* as published by the Free Software Foundation.
*/

package chapters.architecture;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
class Bar
Logger logger = LoggerFactory.getLogger(Bar.class);
public void doIt()
logger.debug("doing my job");



MyAppWithConfigFile.java

/**
* Logback: the reliable, generic, fast and flexible logging framework.
* Copyright (C) 1999-2015, QOS.ch. All rights reserved.
*
* This program and the accompanying materials are dual-licensed under
* either the terms of the Eclipse Public License v1.0 as published by
* the Eclipse Foundation
*
* or (per the licensee's choosing)
*
* under the terms of the GNU Lesser General Public License version 2.1
* as published by the Free Software Foundation.
*/

package chapters.architecture;
//Import SLF4J classes.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.joran.JoranConfigurator;
import ch.qos.logback.core.joran.spi.JoranException;
import ch.qos.logback.core.util.StatusPrinter;
public class MyAppWithConfigFile
public static void main(String[] args)
Logger logger = LoggerFactory.getLogger(MyAppWithConfigFile.class);
LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory();
try
JoranConfigurator configurator = new JoranConfigurator();
lc.reset();
configurator.setContext(lc);
configurator.doConfigure(args[0]);
catch (JoranException je)
StatusPrinter.print(lc.getStatusManager());

logger.info("Entering application.");
Bar bar = new Bar();
bar.doIt();
logger.info("Exiting application.");



SelectionRule.java

/**
* Logback: the reliable, generic, fast and flexible logging framework.
* Copyright (C) 1999-2015, QOS.ch. All rights reserved.
*
* This program and the accompanying materials are dual-licensed under
* either the terms of the Eclipse Public License v1.0 as published by
* the Eclipse Foundation
*
* or (per the licensee's choosing)
*
* under the terms of the GNU Lesser General Public License version 2.1
* as published by the Free Software Foundation.
*/

package chapters.architecture;
import ch.qos.logback.classic.Level;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* @author Ceki Gülcü
*/

public class SelectionRule
public static void main(String[] args)
// get a logger instance named "com.foo". Let us further assume that the
// logger is of type ch.qos.logback.classic.Logger so that we can
// set its level
ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("com.foo");
// set its Level to INFO. The setLevel() method requires a logback logger
logger.setLevel(Level.INFO);
Logger barlogger = LoggerFactory.getLogger("com.foo.Bar");
// This request is enabled, because WARN >= INFO
logger.warn("Low fuel level.");
// This request is disabled, because DEBUG
logger.debug("Starting search for nearest gas station.");
// The logger instance barlogger, named "com.foo.Bar",
// will inherit its level from the logger named
// "com.foo" Thus, the following request is enabled
// because INFO >= INFO.
barlogger.info("Located nearest gas station.");
// This request is disabled, because DEBUG
barlogger.debug("Exiting gas station search");



推荐阅读
  • 设计实战 | 10个Kotlin项目深度解析:首页模块开发详解
    设计实战 | 10个Kotlin项目深度解析:首页模块开发详解 ... [详细]
  • 开发笔记:深入解析Android自定义控件——Button的72种变形技巧
    开发笔记:深入解析Android自定义控件——Button的72种变形技巧 ... [详细]
  • 经过半年的精心整理,我们汇总了当前市场上最全面的Android面试题解析,为移动开发人员的晋升和加薪提供了宝贵的参考资料。本书详细涵盖了从基础到高级的各类面试题,帮助读者全面提升技术实力和面试表现。章节目录包括:- 第一章:Android基础面试题- 第二章:... ... [详细]
  • 在处理遗留数据库的映射时,反向工程是一个重要的初始步骤。由于实体模式已经在数据库系统中存在,Hibernate 提供了自动化工具来简化这一过程,帮助开发人员快速生成持久化类和映射文件。通过反向工程,可以显著提高开发效率并减少手动配置的错误。此外,该工具还支持对现有数据库结构进行分析,自动生成符合 Hibernate 规范的配置文件,从而加速项目的启动和开发周期。 ... [详细]
  • 掌握Android UI设计:利用ZoomControls实现图片缩放功能
    本文介绍了如何在Android应用中通过使用ZoomControls组件来实现图片的缩放功能。ZoomControls提供了一种简单且直观的方式,让用户可以通过点击放大和缩小按钮来调整图片的显示大小。文章详细讲解了ZoomControls的基本用法、布局设置以及与ImageView的结合使用方法,适合初学者快速掌握Android UI设计中的这一重要功能。 ... [详细]
  • Java SE 文件操作类详解与应用
    ### Java SE 文件操作类详解与应用#### 1. File 类##### 1.1 File 类概述File 类是 Java SE 中用于表示文件和目录路径名的对象。它提供了丰富的方法来操作文件和目录,包括创建、删除、重命名文件,以及获取文件属性和信息。通过 File 类,开发者可以轻松地进行文件系统操作,如检查文件是否存在、读取文件内容、列出目录下的文件等。此外,File 类还支持跨平台操作,确保在不同操作系统中的一致性。 ... [详细]
  • 技术分享:深入解析GestureDetector手势识别机制
    技术分享:深入解析GestureDetector手势识别机制 ... [详细]
  • 本文源自极分享,详细内容请参阅原文。技术债务如同信用卡负债,随着时间推移,修复成本会越来越高,因此程序员必须对此有深刻认识。此外,团队应致力于培养一种持续维护和优化代码的文化,以减少技术债务的累积。 ... [详细]
  • React项目基础教程第五课:深入解析组件间通信机制 ... [详细]
  • Netty框架中运用Protobuf实现高效通信协议
    在Netty框架中,通过引入Protobuf来实现高效的通信协议。为了使用Protobuf,需要先准备好环境,包括下载并安装Protobuf的代码生成器`protoc`以及相应的源码包。具体资源可从官方下载页面获取,确保版本兼容性以充分发挥其性能优势。此外,配置好开发环境后,可以通过定义`.proto`文件来自动生成Java类,从而简化数据序列化和反序列化的操作,提高通信效率。 ... [详细]
  • SQLite数据库CRUD操作实例分析与应用
    本文通过分析和实例演示了SQLite数据库中的CRUD(创建、读取、更新和删除)操作,详细介绍了如何在Java环境中使用Person实体类进行数据库操作。文章首先阐述了SQLite数据库的基本概念及其在移动应用开发中的重要性,然后通过具体的代码示例,逐步展示了如何实现对Person实体类的增删改查功能。此外,还讨论了常见错误及其解决方法,为开发者提供了实用的参考和指导。 ... [详细]
  • Python 中 json.dumps() 和 json.loads() 的使用方法详解——Python 面试与 JavaScript 面试必备知识
    在 Python 中,`json.dumps()` 和 `json.loads()` 是处理 JSON 数据的核心函数。`json.dumps()` 用于将字典或其他可序列化对象转换为 JSON 格式的字符串,而 `json.loads()` 则用于将 JSON 字符串解析为 Python 对象。本文详细介绍了这两个函数的使用方法及其在 Python 和 JavaScript 面试中的重要性,帮助读者掌握这些关键技能。 ... [详细]
  • 探索偶数次幂二项式系数的求和方法及其数学意义 ... [详细]
  • 捕获并处理用户输入数字时的异常,提供详细的错误提示与指导
    在用户输入数字时,程序能够有效捕获并处理各种异常情况,如非法字符或格式错误,并提供详尽的错误提示和操作指导,确保用户能够准确输入有效的数字数据。通过这种方式,不仅提高了程序的健壮性和用户体验,还减少了因输入错误导致的系统故障。具体实现中,使用了Java的异常处理机制,结合Scanner类进行输入读取和验证,确保了输入的合法性和准确性。 ... [详细]
  • 为了向用户提供虚拟应用程序,通常会在基础架构中部署StoreFront或Web Interface。为了确保安全的远程访问,通常需要在DMZ中配置Secure Gateway或Access Gateway。本文详细对比了这两种界面工具的功能特性,包括用户管理、安全性、性能优化等方面,为企业选择合适的解决方案提供了全面的参考。 ... [详细]
author-avatar
縌风而行2010
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有